Skip to content

feat: Support clearing nullable fields via explicit nil - #521

Closed
devin-ai-integration[bot] wants to merge 2 commits into
mainfrom
devin/1784563931-clear-nullable-oagen
Closed

feat: Support clearing nullable fields via explicit nil#521
devin-ai-integration[bot] wants to merge 2 commits into
mainfrom
devin/1784563931-clear-nullable-oagen

Conversation

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Description

Lets callers clear a nullable API field (e.g. an Organization/User external_id) through the normal SDK methods, instead of hand-building a raw Net::HTTP request with JSON null.

Previously every optional body param defaulted to nil, and both the generated method and base_client .compact-ed the body — so an explicit external_id: nil was indistinguishable from an omitted argument and got stripped. There was no way to send JSON null.

Now, nullable optional params default to a generated WorkOS::OMIT sentinel:

  • omit the argument → field is left out of the body (unchanged) — same as before
  • pass an explicit nil → field is sent as JSON null (clears it)
  • pass a value → sent as-is
# clears external_id
WorkOS.client.organizations.update_organization(id: org.id, external_id: nil)
WorkOS.client.user_management.update_user(id: user.id, external_id: nil)

# unchanged (omitted)
WorkOS.client.organizations.update_organization(id: org.id, name: "New Name")

Non-nullable optional params keep their exact current behavior (nil still means "omit").

What changed

  • Generated resources (via oagen, PR workos/oagen-emitters#189): nullable optional body params default to WorkOS::OMIT; the generated method compacts only the non-nullable literal and conditionally assigns nullable fields so an explicit nil survives.
  • lib/workos.rb: defines the WorkOS::OMIT sentinel.
  • lib/workos/base_client.rb (hand-maintained): request bodies are no longer .compact-ed, so the intentional nil placed by generated methods serializes to JSON null. Verified safe — all body-building callers (generated + hand-maintained like passwordless/session) already drop unset optionals before the body reaches base_client.

This scope is limited to fields marked nullable in the OpenAPI spec (type: [string, 'null']), matching what the API actually allows to be cleared.

Supersedes the earlier hand-written WorkOS::Null stopgap (#519), which introduced a caller-facing sentinel; this uses the codegen approach agreed on so Ruby/Python/Go stay consistent.

Testing

  • New test/workos/test_nullable_clearing.rb asserts omitted → absent, explicit nil → JSON null, and value → value, for Organization and User external_id.
  • Full suite green (697 runs, 0 failures), StandardRB clean, Zeitwerk eager-load OK.

Documentation

Does this require changes to the WorkOS Docs? E.g. the API Reference or code snippets need updates.

[ ] Yes

If yes, link a related docs PR and add a docs maintainer as a reviewer. Their approval is required.

Link to Devin session: https://app.devin.ai/sessions/127c630f46c54bc0be571814c75047e8

Nullable optional body params now default to a generated WorkOS::OMIT
sentinel. Omitting an argument leaves the field unchanged; passing an
explicit nil sends JSON null to clear it (e.g. Organization/User
external_id). base_client no longer .compact-s the request body so
intentional nils survive serialization.
@devin-ai-integration
devin-ai-integration Bot requested a review from a team as a code owner July 20, 2026 21:44
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author
Original prompt from heather

SYSTEM:
=== BEGIN THREAD HISTORY (in #dse-pre-triage) ===
<most_recent_message>
Heather Faerber (U08CUNLUBT9): @Devin can you update the rubygem based on this feedback?

&gt; Is there a way via the API or rubygem to clear the external_id for an organization (or user)?
&gt;
&gt; In the web UI, we can just delete the field. (It's under Settings | Organization details | Edit details | External ID.)
&gt;
&gt; But when I tried to set it with the rubygem, it doesn't work:
&gt; • nil gets stripped. The request succeeds, but the value doesn't change.
&gt; • "" returns Validation failed
&gt; • Doing the request directly also gets 200, but the value also doesn't change.
&gt;
&gt; ```&gt; org = WorkOS.client.organizations.create_organization(name: "ExtId Clear Test A", external_id: "ext-clear-test-a")
&gt; =&gt; #&lt;WorkOS::Organization object="organization" id="org_01KXT2M2RQYV33VRKJC7CKKNMY" name="ExtId Clear Test A" domains=[] metadata={} external_id="ext-clear-test-a" created_at="2026-07-18T07:39:00.241Z" updated_at="2026-07-18T07:39:00.241Z" allow_profiles_outside_organization=false&gt;
&gt;
&gt; &gt; WorkOS.client.organizations.update_organization(id: org.id, external_id: "")
&gt; (artemis):48:in '&lt;main&gt;': Validation failed (WorkOS::UnprocessableEntityError)
&gt;
&gt; &gt; WorkOS.client.organizations.update_organization(id: org.id, external_id: nil)
&gt; =&gt; `#`&lt;WorkOS::Organization object="organization" id="org_01KXT2M2RQYV33VRKJC7CKKNMY" name="ExtId Clear Test A" domains=[] metadata={} external_id="ext-clear-test-a" created_at="2026-07-18T07:39:00.241Z" updated_at="2026-07-18T07:39:18.505Z" allow_profiles_outside_organization=false&gt;
&gt;
&gt; &gt; WorkOS.client.organizations.get_organization(id: org.id).external_id.inspect
&gt; =&gt; ""ext-clear-test-a""
&gt;
&gt; &gt; WorkOS.client.request(method: :put, path: "/organizations/`#`{org.id}", body: { "external_id" =&gt; nil })
&gt; =&gt; `#`&lt;Net::HTTPOK 200 OK readbody=true&gt;
&gt;
&gt; &gt; Wo... (3272 chars truncated...)

@devin-ai-integration
devin-ai-integration Bot requested a review from a team as a code owner July 20, 2026 21:44
@devin-ai-integration
devin-ai-integration Bot requested a review from tribble July 20, 2026 21:44
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@workos-sdk-automation

Copy link
Copy Markdown
Contributor

🤖 This pull request was closed automatically

It edits files that are auto-generated by (each file has a header comment identifying it as generated). Hand edits to generated code are overwritten the next time the SDK is regenerated from the OpenAPI spec, so they can't be merged.

Generated files changed outside their hand-maintainable regions:

  • lib/workos.rb
  • lib/workos/api_keys.rb
  • lib/workos/authorization.rb
  • lib/workos/connect.rb
  • lib/workos/groups.rb
  • lib/workos/organizations.rb
  • lib/workos/pipes.rb
  • lib/workos/pipes_provider.rb
  • lib/workos/user_management.rb
  • lib/workos/vault.rb

What to do instead

  • Generated code (models, resources, client wiring): make the change upstream in the OpenAPI spec so it lands on the next regeneration.
  • Hand-maintained code inside a generated file: only the regions fenced by @oagen-ignore-start@oagen-ignore-end may be edited by hand. Keep your changes within those fences.

If you believe this was closed in error, a maintainer can reopen the PR.

@greptile-apps

greptile-apps Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces a WorkOS::OMIT sentinel object to differentiate between "omit this field" (unchanged API behavior) and "explicitly set to nil/JSON null" (clears a nullable field) for generated SDK methods. The key mechanism is removing .compact from base_client.rb request builders so intentional nil values survive serialization, while all generated methods guard non-nullable optional params with the existing .compact pattern and conditionally assign nullable fields only when the argument is not OMIT.

  • lib/workos.rb — adds WorkOS::OMIT = Object.new (frozen, with a custom inspect) as the sentinel; Object#equal? identity checks throughout generated code ensure no accidental == coercion.
  • lib/workos/base_client.rb — removes .compact from post_request, put_request, patch_request, and delete_request body builders; safe because all callers compact non-nullable optionals before the body reaches this layer.
  • Generated resource files (organizations.rb, user_management.rb, authorization.rb, connect.rb, groups.rb, pipes.rb, pipes_provider.rb, vault.rb, api_keys.rb) — nullable optional params default to WorkOS::OMIT; concrete-literal params remain nil-defaulted inside .compact hashes; a new auth_methods: nil parameter is also bundled into pipes.rb::create_data_integration.

Confidence Score: 4/5

Safe to merge with one outstanding concern in user_management.rb worth resolving before a public release.

The sentinel design is sound and the base_client change is well-scoped. The main open question is in create_user: every optional field (first_name, last_name, name, email_verified, ip_address, user_agent) was switched to WorkOS::OMIT, meaning an explicit nil now sends JSON null to the API for each of those fields. update_user keeps those same fields nil-defaulted inside .compact, so the wire behaviour diverges between create and update for identical caller code. If the create-user endpoint does not accept null for those fields, callers who currently pass them as nil expecting a silent omit will start seeing API errors after upgrading.

lib/workos/user_management.rb — verify that first_name, last_name, name, email_verified, ip_address, and user_agent are genuinely nullable in the create-user OpenAPI spec before shipping.

Important Files Changed

Filename Overview
lib/workos.rb Adds the WorkOS::OMIT sentinel (frozen Object.new with custom inspect); correctly placed before Zeitwerk setup so it resolves at method-call time for all lazy-loaded resource files.
lib/workos/base_client.rb Removes .compact from all four request builders (post, put, patch, delete); safe because every generated method and hand-maintained caller filters non-nullable nils with .compact on the hash literal before this layer is reached.
lib/workos/user_management.rb create_user moves ALL optional fields (first_name, last_name, name, email_verified, ip_address, user_agent, metadata, external_id) to WorkOS::OMIT, while update_user keeps first_name/last_name/name/email_verified as nil-defaulted in .compact — asymmetric treatment already flagged in previous review comments.
lib/workos/organizations.rb Both create_organization and update_organization consistently apply WorkOS::OMIT for metadata and external_id; non-nullable optionals remain nil-defaulted inside .compact.
lib/workos/authorization.rb Eight methods updated; description fields consistently switched to WorkOS::OMIT with correct conditional assignment pattern throughout.
lib/workos/pipes.rb description and scopes become WorkOS::OMIT across create/update_data_integration; also bundles a new auth_methods: nil non-nullable optional parameter into create_data_integration.
test/workos/test_nullable_clearing.rb Covers the three core cases (omit/nil/value) for Organization and User external_id, plus a cross-cutting sentinel-leak assertion; adequate given all resource files share a single generated template.

Reviews (2): Last reviewed commit: "test: Assert WorkOS::OMIT sentinel never..." | Re-trigger Greptile

Comment thread lib/workos/base_client.rb
Comment thread test/workos/test_nullable_clearing.rb
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Re: the create_user vs update_user asymmetry (Greptile's out-of-diff P1 on lib/workos/user_management.rb) — verified against the OpenAPI spec, this is faithful, not over-broad:

  • CreateUserlandUserDto marks first_name/last_name/name/ip_address/user_agent as type: [string, 'null'] and email_verified as type: [boolean, 'null'] (plus metadata, external_id).
  • UpdateUserlandUserDto marks those same profile fields as plain type: string/boolean — only metadata, external_id, and locale are nullable there.

So the create endpoint genuinely accepts null for those fields and the update endpoint doesn't; the emitter derives WorkOS::OMIT per-operation from type: [..., 'null'] (workos/oagen-emitters#189), which is why they differ. No 422 risk — null is valid server-side exactly where the sentinel was applied. The only behavior change is the intended one: an explicit nil on a nullable field now clears it instead of being silently dropped.

@workos-sdk-automation

Copy link
Copy Markdown
Contributor

🤖 This pull request was closed automatically

It edits files that are auto-generated by (each file has a header comment identifying it as generated). Hand edits to generated code are overwritten the next time the SDK is regenerated from the OpenAPI spec, so they can't be merged.

Generated files changed outside their hand-maintainable regions:

  • lib/workos.rb
  • lib/workos/api_keys.rb
  • lib/workos/authorization.rb
  • lib/workos/connect.rb
  • lib/workos/groups.rb
  • lib/workos/organizations.rb
  • lib/workos/pipes.rb
  • lib/workos/pipes_provider.rb
  • lib/workos/user_management.rb
  • lib/workos/vault.rb

What to do instead

  • Generated code (models, resources, client wiring): make the change upstream in the OpenAPI spec so it lands on the next regeneration.
  • Hand-maintained code inside a generated file: only the regions fenced by @oagen-ignore-start@oagen-ignore-end may be edited by hand. Keep your changes within those fences.

If you believe this was closed in error, a maintainer can reopen the PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants